Migrate Pages Router to App Router - #267
Draft
cooperability wants to merge 8 commits into
Draft
Conversation
…TODOs Routes move from `src/pages/**` to `src/app/**`. Every page is a server component; interactivity is pushed into named `'use client'` leaves rather than marking whole routes client-side. Behaviour intended to change: - `/api/hello` is deleted (was an unused scaffold). A replacement would now be an `app/api/*/route.ts` Route Handler. - The homepage gains a Mandelbrot Explorer CTA beside the Prompt Composer one. - Canonical host standardises on `https://www.cooperability.com`, matching `next-sitemap.config.js`. The old homepage canonical pointed at the apex. - `yarn access` scores will move: collapsing two nested `next-themes` providers into one made `NEXT_PUBLIC_AXE_FORCE_THEME` effective for the first time. Behaviour deliberately preserved, and verified against a production build: per-route `og:title`, favicon and `apple-mobile-web-app-capable` on all seven routes; unknown resource slugs 404 rather than 500; `/` stays in the sitemap (12 URLs); the homepage quote still varies per request. `next build` is pinned to `--webpack`: Next 16 defaults to Turbopack, which cannot resolve `next/package.json` under Yarn PnP. This unblocks the build without pre-empting the pnpm migration. Also folded in, since all of it was making the working tree dirty on every build or cross-OS install: - Untrack `tsconfig.tsbuildinfo`, `accessibility-reports/` and `public/sw.js` (the latter two were already gitignored yet tracked), and ignore platform-native `.yarn/cache` archives. A Windows `yarn install` had staged win32 `sharp`/`swc` binaries over the linux ones Vercel builds against. - Track `next-env.d.ts` deliberately instead of ignoring-yet-tracking it; `yarn typecheck` needs its CSS-module and image declarations. - Documentation WIP for `docs/Tooling.md` and the Prompt Composer README. Migration nuances that cost real debugging time are written up in README.md under "App Router migration notes" rather than left in this message. Co-authored-by: Cursor <cursoragent@cursor.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
cooperability
added a commit
that referenced
this pull request
Aug 26, 2026
Fourth single-variable attempt at the failing preview deploy, and the first one aimed at the builder rather than the package manager. The App Router branch (#267) explicitly pins `next build --webpack`, which is a deliberate opt-out of the Next 16 default. The most likely reason to add that flag is that Turbopack does not build this project on Vercel -- which would mean the failing deploys here have nothing to do with pnpm at all, and everything to do with this branch restoring the default builder while removing the `ls -la .yarn` debug probes from the same script. If this deploy goes green, the builder was the cause and the pnpm migration was never implicated. That also revises the recommendation in docs/PNPM-MIGRATION.md section 8: Turbopack's 2.4x faster build is not available here until whatever breaks it on Vercel is understood. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013RtsQWaEaAe4iVnSoUCDfY
cooperability
added a commit
that referenced
this pull request
Aug 26, 2026
Pinning --webpack did not fix the preview deploy, so the builder is not implicated and there is no reason to give up Turbopack's 2.4x faster build (measured, both sides, in docs/PNPM-MIGRATION.md section 8). Parking the Vercel failure here rather than continuing to guess. Four hypotheses were each tested by pushing them alone, and each was wrong: 1. corepack not enabled -> restored ENABLE_EXPERIMENTAL_COREPACK=1 2. engines.node semver range -> back to the "22.x" major selector 3. pnpm-workspace packages: [.] -> removed the monorepo signal 4. Turbopack failing on Vercel -> pinned --webpack Changes 1-3 are correct regardless and are kept; 4 is reverted here. What is known: main and PR #267 both deploy successfully, and every commit on this branch fails, so the cause is on this branch. GitHub Actions runs the same install and the same production build on ubuntu and passes in ~60s, so it is specific to the Vercel environment rather than to pnpm or the build. What is needed: the build log, which cannot be read from here -- there is no Vercel CLI and no token in this environment. One command answers it: npx vercel login && npx vercel inspect <deployment-url> --logs Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013RtsQWaEaAe4iVnSoUCDfY
The App Router migration landed without a working lint gate. Restoring it turned out to also mean restoring the dev server, and both then surfaced real defects — so this is one causal chain rather than three unrelated changes. `yarn lint` ran zero rules; it crashed. ESLint 10 is ahead of this stack: eslint-plugin-react, eslint-plugin-jsx-a11y and eslint-plugin-import have no ESLint 10 release at all, so there was nothing to upgrade to. Pinned to 9.39.5, which every plugin supports — not a weakening, since the alternative was no gate. `eslint-config-next` 16 ships a native flat config, so the FlatCompat shim is gone along with `@eslint/eslintrc` and the never-imported `@eslint/compat`, and React version detection is pinned rather than autodetected (detection calls an API v10 removed). `yarn dev` was broken outright, for the reason `next build` already had a workaround for: Turbopack cannot resolve `next/package.json` under Yarn PnP, and only `build` had been pinned to `--webpack`. `dev` and `analyze` now pin it too. `analyze` needed it regardless — `@next/bundle-analyzer` is a webpack plugin that Turbopack ignores. Fixing `dev` generated `.next/dev/types` for the first time, which immediately caught `layout.tsx` exporting `siteTitle`. Next type-checks route files against a fixed set of allowed exports, so that had been a latent build error nothing could see. With lint running, five `react-hooks/set-state-in-effect` errors surfaced. All are genuine extra render passes, and all are fixed at the pattern level rather than suppressed: - `useHydrated` (new, built on `useSyncExternalStore`) replaces the `useState(false)` + `useEffect(() => setMounted(true))` idiom in ThemeSwitch and ActiveIcon, which cost a second render on every mount. - `useResponsive` reads the viewport through `useSyncExternalStore` rather than seeding it from an effect, keeping the debounced resize subscription. - OpioidConverter derives both equivalences with `useMemo`. They had been mirrored into state, so every dose change painted the previous total first. - PromptComposer adjusts its edit buffer during render instead of re-syncing in an effect, which also removes a flash of the empty-state placeholder. Covered by 18 new tests over the dosage arithmetic and the viewport hook, each proven able to fail by mutating the code under test and confirming red. Also in this pass: - Security headers on every route: `X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options`, `Permissions-Policy` and HSTS. No `preload` on HSTS, which is impractical to reverse. The CSP ships as `Content-Security-Policy-Report- Only`: next-themes and Next's own bootstrap both inject inline script, so enforcing it needs a per-request nonce, which would force dynamic rendering on every route. - The service worker precached nothing usable. It globbed `.next` filesystem paths, so entries like `static/chunks/x.js` resolved to `/static/chunks/x.js` and 404'd, and it also swept in `.next/server` and `.next/cache`, neither of which is reachable over HTTP. Now 42 entries under `/_next/static/`, each verified to return 200. - Remove `prop-types`; it was never imported. The blocker recorded against it did not exist: `YARN_CACHE_FOLDER` is set in `vercel.json` and locally, so Yarn writes outside the tracked `.yarn/cache` and dependency changes produce no cache churn. `yarn access` is unblocked but not re-measured — it now gets past lint and fails only for want of a Chrome binary in the sandbox this was fixed in. Co-authored-by: Cursor <cursoragent@cursor.com>
The previous commit pinned ESLint to 9.39.5 on the finding that eslint-plugin-react, eslint-plugin-jsx-a11y and eslint-plugin-import have no ESLint 10 release. That reading of the registry is still accurate today. The conclusion drawn from it was not: peer ranges are metadata, and all three plugins run correctly on ESLint 10.10.0. The crash they were blamed for came from somewhere else. The actual blocker is @typescript-eslint/scope-manager 8.x, which does not implement the addGlobals method ESLint 10 calls when a sourceType script file declares globals. eslint-config-next installs the TypeScript parser for every file it matches, so left unscoped it claimed the root CommonJS configs and crashed on them. Scoping eslint-config-next to application sources fixes it without giving up a single rule. Measured rather than assumed. Active rules on src/app/page.tsx go from 126 to 130: nothing lost, and no-unassigned-vars, no-useless-assignment and preserve-caught-error gained. react-hooks/set-state-in-effect, jsx-a11y/alt-text, @typescript-eslint/no-explicit-any and @next/next/no-img-element were each confirmed to fire against a probe file that violates them. Two accidents of the old config go with it: - eslint-plugin-mdx defaults to every markdown file in the repo, so the gate was parsing README.md and the .claude and .cursor agent docs. It is now scoped to src/resources, the MDX that actually ships. Fenced code blocks are no longer linted, which is a real reduction: that path re-enters the linter through the plugin's own processor and is the one place in this stack that has not kept up with ESLint's SourceCode API. - The MDX rule override turned off react/jsx-no-undef. The rule that fires on MDXRemote components is core no-undef, so the suppression had never applied to anything. --ext was removed in ESLint 10, so the lint script is now plain eslint . and the file set is declared in the config. Separately, .pnp.cjs on this branch carried 1552 package paths pointing into a Cursor agent sandbox under AppData/Local/Temp, a directory that no longer exists. main resolves the same packages through ./.yarn/cache. Any checkout of this branch failed lint, typecheck and jest with "Required package missing from disk" until yarn install regenerated the manifest. CI did not catch it because yarn install --immutable regenerates .pnp.cjs and refetches from the network, and because no workflow runs lint, typecheck, jest or build. Regenerating also resyncs .yarn/cache, which the lockfile had drifted 41 packages away from. Verified: lint, typecheck, jest (25 tests), build and yarn install all exit 0. Sitemap still 12 URLs, service worker still 42 entries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AF88waRzZMhZ7c39eUVtEL
Moving the edit buffer from an effect to a render-phase adjustment left the component with nothing to show on load. `editedPrompt` starts as an empty string, `lastCompiled` started as `compiledPrompt`, so the two already agreed on the first render and the sync branch never ran. The audience toggle always contributes a sentence, so `compiledPrompt` is never empty and the branch was not reached later either. The result was a demo that renders no textarea at all: the empty-state placeholder shows instead, Copy is disabled and the word count reads zero, where main opens with the compiled default prompt in the box. The homepage CTA added earlier in this branch links straight to it. Seeding `lastCompiled` to null makes the first render take the branch, which is what the pattern requires. React re-runs the render before committing, so there is still no intermediate paint. Three tests cover it, and all three were confirmed to fail against the old seed and pass against the new one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AF88waRzZMhZ7c39eUVtEL
16.2.6 is covered by both of these, and both are fixed in 16.3.3: - GHSA-p293-qw3h-jr36, unauthenticated remote code execution on windows-hosted servers. - Unauthenticated remote code execution in the Image Optimization API when AVIF files are used. The second is reachable here. next.config.js allows remote images from skillicons.dev with dangerouslyAllowSVG, so the optimizer runs in production. This predates the branch and is equally true of main, but `yarn npm audit --severity critical` is the repository's only CI gate and it fails on 16.2.6. It now exits 0. 16.3.4 also adds root-params.d.ts to next-env.d.ts, which is why that tracked file moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AF88waRzZMhZ7c39eUVtEL
The tracked cache could never do the job it was there for. next and
@next-swc are excluded because they exceed GitHub's file size limit, the
win32 and darwin archives are excluded by policy, and Vercel does not read
it at all: vercel.json points YARN_CACHE_FOLDER at .next/cache/yarn. What
remained was 1393 archives that no build consumes.
It was also actively harmful. The ignore rules stopped a developer adding
the wrong platform's binaries, but nothing stopped one removing the right
platform's. Every `yarn install` on Windows deletes the five linux archives
Vercel builds against:
D .yarn/cache/@img-sharp-libvips-linux-x64-...zip
D .yarn/cache/@img-sharp-linux-x64-...zip
D .yarn/cache/@swc-core-linux-x64-gnu-...zip
D .yarn/cache/@unrs-resolver-binding-linux-x64-gnu-1.11.1-...zip
D .yarn/cache/@unrs-resolver-binding-linux-x64-gnu-1.9.2-...zip
Committing that set would have removed image optimization and compilation
binaries from the deploy's cache.
`yarn install --immutable` is what actually guarantees a reproducible
install, and it stays. The lockfile is unchanged by this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AF88waRzZMhZ7c39eUVtEL
The repository had one workflow, a dependency audit. Nothing ran lint, typecheck, jest or build on a pull request, which is why a branch reached this PR with three green checks while .pnp.cjs pointed at a temp directory that no longer existed. Every gate the PR description reports as clean was failing on a fresh checkout, and `yarn install --immutable` regenerates that manifest instead of failing on it, so CI could not see the fault. These four steps would have caught it on push. Concurrency is set so a newer push cancels the run it supersedes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AF88waRzZMhZ7c39eUVtEL
The Sidebar branched on `useTheme()` during render. next-themes cannot know the theme on the server, so it returned undefined there and the Sidebar took its light branch, while the provider had already read storage before React ran on the client and the hydration render took the dark one. React reported the className and the --inverse-bg/--inverse-text custom properties as mismatched and refused to patch them up. The repository already had the fix for this shape: ThemeSwitch and ActiveIcon gate on `useHydrated`. Sidebar was missed, and so were AccordionTrigger and AccordionContent, which server-render on /demos and /resources/[slug] and would have produced the same mismatch as soon as the Sidebar stopped shadowing it in the error. `useResolvedTheme` wraps the gate so the three call sites cannot get it wrong individually. It reads `resolvedTheme` rather than `theme`, which fixes a second bug in the Sidebar: with enableSystem, a user who has never chosen a theme has `theme === 'system'`, which is neither 'light' nor 'dark', so the Sidebar rendered light for everyone on a system-dark setting. Five tests, on the invariant that actually prevents the mismatch: server markup must not depend on the theme. All five confirmed to fail against the old code and pass against the new. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AF88waRzZMhZ7c39eUVtEL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the App Router item in the README modernization TODO, plus the adjacent ones it turned out to depend on.
What changed
Routes move from
src/pages/**tosrc/app/**.src/pagesis gone entirely. Every page is a server component; interactivity is pushed down into named'use client'leaves (quote-box.tsx,resources-list.tsx, one*-client.tsxper applet) rather than marking whole routes client-side.Net -1633 lines across 59 files, mostly because
getStaticProps/getServerSidePropsplumbing and thegetLayoutpattern collapse into the file conventions.The nuances, which is the actually interesting part
These are written up in full in
README.mdunder App Router migration notes. The ones that cost real debugging time:Metadata inheritance replaces, it does not deep-merge. Setting
icons: { apple: … }on a page silently drops the layout'sicon. That is how the favicon vanished from the three applet routes mid-migration — and the check that "found" it again was reading a stale dev server on a port I thought I'd killed. Every metadata claim in this PR was re-verified against a fresh production build on an unused port.openGraph.titlehas its own resolution chain and never derives fromtitle. Atitle.templateon the root layout looks like the fix but only applies to children that set their ownopenGraph.title; children setting justtitleinherit the parent's resolveddefaultverbatim. Five routes were serving the bare site title as their share card before I set each one explicitly. Worth notingmainhad the same bug hidden — it emitted<meta name="og:title">, and OG scrapers only readproperty, so those tags were being ignored outright.generateStaticParamsis notgetStaticPaths: { fallback: false }. It defaults todynamicParams: true, so an unknown slug renders on demand and 500s where it used to 404. Needs an explicitexport const dynamicParams = false.next/dynamicwithssr: falseis illegal in a server component. Hence the serverpage.tsx(which ownsmetadata) plus thin client wrapper per applet, rather than one merged client route.force-dynamicis load-bearing, and its absence is silent. Without it the homepage prerenders and the per-request quote freezes at build time — no warning, just a page that stops changing.next-sitemapnever readsapp-build-manifest. Aforce-dynamicroute appears in neither manifest it does read, so/dropped out of the sitemap. Restored viaadditionalPaths.public/sitemap*.xmlis gitignored, so this class of regression is structurally invisible to code review — the reason it's a README note and not just a fix.next-themesshort-circuits a nested provider to a Fragment, so the inner one's props had been dead code. Collapsing to one provider madeNEXT_PUBLIC_AXE_FORCE_THEMEtake effect for the first time — expectyarn accessnumbers to move, and treat the new figures as the baseline.Verified against a production build
yarn typecheckandyarn jest --ciclean. Per-route checks againstnext start:og:titledistinct per routeapple-mobile-web-app-capable/Behaviour that intentionally changed
/api/hellois deleted — unused scaffold; a replacement would now beapp/api/*/route.ts.www, matchingnext-sitemap.config.js. The old homepage canonical pointed at the apex, and the JSON-LD disagreed with both. If the apex is what you actually want, that's one line inmetadataBaseplus the sitemap config — flagged as a TODO.Build system
next buildis pinned to--webpack. Next 16 defaults to Turbopack, which cannot resolvenext/package.jsonunder Yarn PnP — plainnext buildfails outright. This unblocks the build without pre-empting the pnpm migration the README already argues for.yarn lintis broken onmainand stays broken here: an ESLint/typescript-eslintmajor mismatch throwsClass extends value undefined. Out of scope, left as a TODO, but it means jsx-a11y did not run on this diff — worth knowing when reviewing the client boundaries.Repo hygiene folded in
All of this was dirtying the working tree on every build or cross-OS install, which is what made the migration diff hard to read in the first place:
tsconfig.tsbuildinfo,accessibility-reports/andpublic/sw.js— the latter two were already gitignored yet tracked, so the ignore rule was doing nothing..yarn/cachearchives. A Windowsyarn installhad staged win32sharp/swc/unrs-resolverbinaries over the linux ones Vercel builds against (~49 MB each way). Committing that set would likely have broken the next deploy's image optimization and compilation, so it is deliberately not in this PR.next-env.d.tswas the mirror-image problem — ignored yet tracked, and rewritten by every build. Now tracked deliberately, becauseyarn typecheckneeds its CSS-module and image declarations.Known gaps, filed not fixed
prop-typesremoval deferred — dropping a dep regenerates.pnp.cjs/yarn.lockand writes new tracked.yarn/cachezips, which belongs in its own PR.@vercel/og) yet;og:titleis correct but there's no card art.Draft rather than ready-for-review: the
yarn accessbaseline needs re-measuring now that theming actually works, and I'd rather you sanity-check thewwwcanonical decision before this merges.Made with Cursor
Update: the toolchain repair (second commit)
The first commit noted that
yarn lintwas broken onmainand stayed broken, so jsx-a11y never ran on the migration diff. Fixing that turned into one causal chain worth reading in order, because each step uncovered the next.1. ESLint 10 is ahead of this stack
The crash (
TypeError: Class extends value undefined) looked like one stale package. It wasn't. Surveying every plugin's peer range:eslint-plugin-react^9.7eslint-plugin-jsx-a11y^9eslint-plugin-import^9eslint-plugin-react-hooks^10✅typescript-eslint^10✅Three of the plugins
eslint-config-nextpulls have no ESLint 10 release at all — includingjsx-a11y, the one this project most cares about. Soeslintmoves to 9.39.5. That is a downgrade, and it is not weakening the gate: the alternative was no gate at all.Two related things fell out:
FlatCompatis obsolete here.eslint-config-next16 ships native flat config ateslint-config-next/core-web-vitals; wrapping it inFlatCompatthrowsConverting circular structure to JSON. Dropping the shim removed@eslint/eslintrcand@eslint/compat(the latter was inpackage.jsonbut never imported).eslint-config-nextsetsreact: { version: 'detect' }, and detection calls an API v10 removed. Reading the installed version directly is immune to that and faster.eslint-config-prettieralso moved to the end of the array, where it can actually switch off the stylistic rules of the configs before it.2.
yarn devwas broken outrightRestoring lint exposed this:
yarn devfails completely under Yarn PnP, because Next 16 defaults to Turbopack and Turbopack can't resolvenext/package.json. This is the same root cause the first commit pinned--webpackfor — but onlybuildhad been fixed.devandanalyzenow pin it too.analyzeneeded it regardless, since@next/bundle-analyzeris a webpack plugin Turbopack ignores entirely.3. Which surfaced a latent build error
Getting
devrunning generated.next/dev/typesfor the first time, which immediately failed typecheck:layout.tsxexportedsiteTitle. Next type-checks route files against a fixed set of permitted exports. Nothing could see this before, because the types that enforce it were never generated.Worth knowing as a footgun:
yarn typecheckis stricter afteryarn devhas run, becausetsconfig.jsonincludes.next/dev/types/**.4. Five real render bugs, fixed at the pattern level
With lint running,
react-hooks/set-state-in-effectflagged five genuine extra render passes. None are suppressed:useState(false)+useEffect(setMounted)useHydrated()onuseSyncExternalStore— no effect, no second passuseResponsiveuseSyncExternalStore, debounce preserveduseMemo; every dose change had painted the previous total first18 new tests, each proven able to fail: mutating
Math.pow(dose, 2)todose * 2and the resize debounce from 100ms to 900ms turned exactly the three intended assertions red, then restored green. The dosage arithmetic in particular is now locked down — it's the highest-consequence code in the repo and had no coverage.5. Other fixes in the same pass
Security headers on every route (closes a README TODO):
X-Content-Type-Options,Referrer-Policy,X-Frame-Options,Permissions-Policy, HSTS. Verified present on both root and nested routes.preload— that puts the apex on browser preload lists and is impractical to reverse. It should be a decision, not a side effect.Content-Security-Policy-Report-Only, and still needs promoting. It cannot be enforced as written:next-themesand Next's bootstrap both inject inline<script>, so a real policy needs a per-request nonce — which forces dynamic rendering on every route. Check a preview deploy's console, then decide if that trade is worth it.The service worker was precaching nothing.
build-sw.mjsglobbed the.nextfilesystem tree, so entries came out asstatic/chunks/x.jsand the browser requested/static/chunks/x.js. Confirmed 404 by direct request. It also swept in.next/serverand.next/cache, neither reachable over HTTP. Now 42 entries under/_next/static/, each verified 200.prop-typesremoved. The blocker recorded against it in the README didn't exist:YARN_CACHE_FOLDERis set invercel.jsonand in local shells, so Yarn writes outside the tracked.yarn/cacheand dependency changes produce no cache churn. (Corollary worth noting: zero-install is already not in effect for this repo.)Verification
lint·typecheck·jest(25 tests) ·build·yarn install --immutableall exit 0. Sitemap still 12 URLs. Working tree stays clean across a full build.Not verified, and I'd rather say so:
yarn accessnow gets past lint but can't complete here — the agent sandbox has no Chrome binary. Please run it locally. Treat the result as a new baseline rather than a regression, because collapsing the nestedThemeProviderin the first commit madeNEXT_PUBLIC_AXE_FORCE_THEMEeffective for the first time.Still open
public/assets; the manifest is scoped to.next/static, and there's still no offline fallback route.wwwvs apex canonical decision.loading.tsxstreaming boundaries yet — deliberately skipped, since all these routes are statically generated and the win is small.